// <copyright file="RemoteWebDriver.cs" company="Selenium Committers">
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.
// </copyright>

using OpenQA.Selenium.Internal.Logging;
using OpenQA.Selenium.DevTools;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;

namespace OpenQA.Selenium.Remote;

/// <summary>
/// Provides a way to use the driver through
/// </summary>
/// /// <example>
/// <code>
/// [TestFixture]
/// public class Testing
/// {
///     private IWebDriver driver;
///     <para></para>
///     [SetUp]
///     public void SetUp()
///     {
///         driver = new RemoteWebDriver(new Uri("http://127.0.0.1:4444/wd/hub"),new FirefoxOptions());
///     }
///     <para></para>
///     [Test]
///     public void TestGoogle()
///     {
///         driver.Navigate().GoToUrl("http://www.google.co.uk");
///         /*
///         *   Rest of the test
///         */
///     }
///     <para></para>
///     [TearDown]
///     public void TearDown()
///     {
///         driver.Quit();
///     }
/// }
/// </code>
/// </example>
public class RemoteWebDriver : WebDriver, IDevTools, IHasDownloads
{
    private static readonly ILogger _logger = OpenQA.Selenium.Internal.Logging.Log.GetLogger(typeof(RemoteWebDriver));

    /// <summary>
    /// The name of the Selenium grid remote DevTools end point capability.
    /// </summary>
    public readonly string RemoteDevToolsEndPointCapabilityName = "se:cdp";

    /// <summary>
    /// The name of the Selenium remote DevTools version capability.
    /// </summary>
    public readonly string RemoteDevToolsVersionCapabilityName = "se:cdpVersion";

    private const string DefaultRemoteServerUrl = "http://127.0.0.1:4444/wd/hub";

    private DevToolsSession? devToolsSession;

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class. This constructor defaults proxy to http://127.0.0.1:4444/wd/hub
    /// </summary>
    /// <param name="options">An <see cref="DriverOptions"/> object containing the desired capabilities of the browser.</param>
    public RemoteWebDriver(DriverOptions options)
        : this(ConvertOptionsToCapabilities(options))
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class. This constructor defaults proxy to http://127.0.0.1:4444/wd/hub
    /// </summary>
    /// <param name="capabilities">An <see cref="ICapabilities"/> object containing the desired capabilities of the browser.</param>
    public RemoteWebDriver(ICapabilities capabilities)
        : this(new Uri(DefaultRemoteServerUrl), capabilities)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class. This constructor defaults proxy to http://127.0.0.1:4444/wd/hub
    /// </summary>
    /// <param name="remoteAddress">URI containing the address of the WebDriver remote server (e.g. http://127.0.0.1:4444/wd/hub).</param>
    /// <param name="options">An <see cref="DriverOptions"/> object containing the desired capabilities of the browser.</param>
    public RemoteWebDriver(Uri remoteAddress, DriverOptions options)
        : this(remoteAddress, ConvertOptionsToCapabilities(options))
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class
    /// </summary>
    /// <param name="remoteAddress">URI containing the address of the WebDriver remote server (e.g. http://127.0.0.1:4444/wd/hub).</param>
    /// <param name="capabilities">An <see cref="ICapabilities"/> object containing the desired capabilities of the browser.</param>
    public RemoteWebDriver(Uri remoteAddress, ICapabilities capabilities)
        : this(remoteAddress, capabilities, RemoteWebDriver.DefaultCommandTimeout)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class using the specified remote address, desired capabilities, and command timeout.
    /// </summary>
    /// <param name="remoteAddress">URI containing the address of the WebDriver remote server (e.g. http://127.0.0.1:4444/wd/hub).</param>
    /// <param name="capabilities">An <see cref="ICapabilities"/> object containing the desired capabilities of the browser.</param>
    /// <param name="commandTimeout">The maximum amount of time to wait for each command.</param>
    public RemoteWebDriver(Uri remoteAddress, ICapabilities capabilities, TimeSpan commandTimeout)
        : this(new HttpCommandExecutor(remoteAddress, commandTimeout), capabilities)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="RemoteWebDriver"/> class
    /// </summary>
    /// <param name="commandExecutor">An <see cref="ICommandExecutor"/> object which executes commands for the driver.</param>
    /// <param name="capabilities">An <see cref="ICapabilities"/> object containing the desired capabilities of the browser.</param>
    public RemoteWebDriver(ICommandExecutor commandExecutor, ICapabilities capabilities)
        : base(commandExecutor, capabilities)
    {
    }

    /// <summary>
    /// Gets a value indicating whether a DevTools session is active.
    /// </summary>
    [MemberNotNullWhen(true, nameof(devToolsSession))]
    public bool HasActiveDevToolsSession => this.devToolsSession != null;

    /// <summary>
    /// Finds the first element in the page that matches the ID supplied
    /// </summary>
    /// <param name="id">ID of the element</param>
    /// <returns>IWebElement object so that you can interact with that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementById("id")
    /// </code>
    /// </example>
    public IWebElement FindElementById(string id)
    {
        return this.FindElement("css selector", "#" + By.EscapeCssSelector(id));
    }

    /// <summary>
    /// Finds the first element in the page that matches the ID supplied
    /// </summary>
    /// <param name="id">ID of the Element</param>
    /// <returns>ReadOnlyCollection of Elements that match the object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsById("id")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsById(string id)
    {
        string selector = By.EscapeCssSelector(id);
        if (string.IsNullOrEmpty(selector))
        {
            // Finding multiple elements with an empty ID will return
            // an empty list. However, finding by a CSS selector of '#'
            // throws an exception, even in the multiple elements case,
            // which means we need to short-circuit that behavior.
            return new List<IWebElement>().AsReadOnly();
        }

        return this.FindElements("css selector", "#" + selector);
    }

    /// <summary>
    /// Finds the first element in the page that matches the CSS Class supplied
    /// </summary>
    /// <param name="className">className of the</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementByClassName("classname")
    /// </code>
    /// </example>
    public IWebElement FindElementByClassName(string className)
    {
        string selector = By.EscapeCssSelector(className);
        if (selector.Contains(" "))
        {
            // Finding elements by class name with whitespace is not allowed.
            // However, converting the single class name to a valid CSS selector
            // by prepending a '.' may result in a still-valid, but incorrect
            // selector. Thus, we short-circuit that behavior here.
            throw new InvalidSelectorException("Compound class names not allowed. Cannot have whitespace in class name. Use CSS selectors instead.");
        }

        return this.FindElement("css selector", "." + selector);
    }

    /// <summary>
    /// Finds a list of elements that match the class name supplied
    /// </summary>
    /// <param name="className">CSS class Name on the element</param>
    /// <returns>ReadOnlyCollection of IWebElement object so that you can interact with those objects</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByClassName("classname")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByClassName(string className)
    {
        string selector = By.EscapeCssSelector(className);
        if (selector.Contains(" "))
        {
            // Finding elements by class name with whitespace is not allowed.
            // However, converting the single class name to a valid CSS selector
            // by prepending a '.' may result in a still-valid, but incorrect
            // selector. Thus, we short-circuit that behavior here.
            throw new InvalidSelectorException("Compound class names not allowed. Cannot have whitespace in class name. Use CSS selectors instead.");
        }

        return this.FindElements("css selector", "." + selector);
    }

    /// <summary>
    /// Finds the first of elements that match the link text supplied
    /// </summary>
    /// <param name="linkText">Link text of element </param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementsByLinkText("linktext")
    /// </code>
    /// </example>
    public IWebElement FindElementByLinkText(string linkText)
    {
        return this.FindElement("link text", linkText);
    }

    /// <summary>
    /// Finds a list of elements that match the link text supplied
    /// </summary>
    /// <param name="linkText">Link text of element</param>
    /// <returns>ReadOnlyCollection<![CDATA[<IWebElement>]]> object so that you can interact with those objects</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByClassName("classname")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByLinkText(string linkText)
    {
        return this.FindElements("link text", linkText);
    }

    /// <summary>
    /// Finds the first of elements that match the part of the link text supplied
    /// </summary>
    /// <param name="partialLinkText">part of the link text</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementsByPartialLinkText("partOfLink")
    /// </code>
    /// </example>
    public IWebElement FindElementByPartialLinkText(string partialLinkText)
    {
        return this.FindElement("partial link text", partialLinkText);
    }

    /// <summary>
    /// Finds a list of elements that match the class name supplied
    /// </summary>
    /// <param name="partialLinkText">part of the link text</param>
    /// <returns>ReadOnlyCollection<![CDATA[<IWebElement>]]> objects so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByPartialLinkText("partOfTheLink")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByPartialLinkText(string partialLinkText)
    {
        return this.FindElements("partial link text", partialLinkText);
    }

    /// <summary>
    /// Finds the first of elements that match the name supplied
    /// </summary>
    /// <param name="name">Name of the element on the page</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// elem = driver.FindElementsByName("name")
    /// </code>
    /// </example>
    public IWebElement FindElementByName(string name)
    {
        return this.FindElement("css selector", "*[name=\"" + name + "\"]");
    }

    /// <summary>
    /// Finds a list of elements that match the name supplied
    /// </summary>
    /// <param name="name">Name of element</param>
    /// <returns>ReadOnlyCollect of IWebElement objects so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByName("name")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByName(string name)
    {
        return this.FindElements("css selector", "*[name=\"" + name + "\"]");
    }

    /// <summary>
    /// Finds the first of elements that match the DOM Tag supplied
    /// </summary>
    /// <param name="tagName">DOM tag Name of the element being searched</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementsByTagName("tag")
    /// </code>
    /// </example>
    public IWebElement FindElementByTagName(string tagName)
    {
        return this.FindElement("css selector", tagName);
    }

    /// <summary>
    /// Finds a list of elements that match the DOM Tag supplied
    /// </summary>
    /// <param name="tagName">DOM tag Name of element being searched</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByTagName("tag")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByTagName(string tagName)
    {
        return this.FindElements("css selector", tagName);
    }

    /// <summary>
    /// Finds the first of elements that match the XPath supplied
    /// </summary>
    /// <param name="xpath">xpath to the element</param>
    /// <returns>IWebElement object so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// IWebElement elem = driver.FindElementsByXPath("//table/tbody/tr/td/a");
    /// </code>
    /// </example>
    public IWebElement FindElementByXPath(string xpath)
    {
        return this.FindElement("xpath", xpath);
    }

    /// <summary>
    /// Finds a list of elements that match the XPath supplied
    /// </summary>
    /// <param name="xpath">xpath to the element</param>
    /// <returns>ReadOnlyCollection of IWebElement objects so that you can interact that object</returns>
    /// <example>
    /// <code>
    /// IWebDriver driver = new RemoteWebDriver(new FirefoxOptions());
    /// ReadOnlyCollection<![CDATA[<IWebElement>]]> elem = driver.FindElementsByXpath("//tr/td/a")
    /// </code>
    /// </example>
    public ReadOnlyCollection<IWebElement> FindElementsByXPath(string xpath)
    {
        return this.FindElements("xpath", xpath);
    }

    /// <summary>
    /// Finds the first element matching the specified CSS selector.
    /// </summary>
    /// <param name="cssSelector">The CSS selector to match.</param>
    /// <returns>The first <see cref="IWebElement"/> matching the criteria.</returns>
    public IWebElement FindElementByCssSelector(string cssSelector)
    {
        return this.FindElement("css selector", cssSelector);
    }

    /// <summary>
    /// Finds all elements matching the specified CSS selector.
    /// </summary>
    /// <param name="cssSelector">The CSS selector to match.</param>
    /// <returns>A <see cref="ReadOnlyCollection{T}"/> containing all
    /// <see cref="IWebElement">IWebElements</see> matching the criteria.</returns>
    public ReadOnlyCollection<IWebElement> FindElementsByCssSelector(string cssSelector)
    {
        return this.FindElements("css selector", cssSelector);
    }

    /// <summary>
    /// Creates a session to communicate with a browser using a Developer Tools debugging protocol.
    /// </summary>
    /// <returns>The active session to use to communicate with the Developer Tools debugging protocol.</returns>
    [RequiresUnreferencedCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    [RequiresDynamicCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    public DevToolsSession GetDevToolsSession()
    {
        if (this.Capabilities.GetCapability(CapabilityType.BrowserName) is "firefox")
        {
            if (_logger.IsEnabled(LogEventLevel.Warn))
            {
                _logger.Warn("CDP support for Firefox is deprecated and will be removed in future versions. Please switch to WebDriver BiDi.");
            }
        }

        return GetDevToolsSession(new DevToolsOptions() { ProtocolVersion = DevToolsSession.AutoDetectDevToolsProtocolVersion });
    }

    /// <summary>
    /// Creates a session to communicate with a browser using a Developer Tools debugging protocol.
    /// </summary>
    /// <returns>The active session to use to communicate with the Developer Tools debugging protocol.</returns>
    [RequiresUnreferencedCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    [RequiresDynamicCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    public DevToolsSession GetDevToolsSession(DevToolsOptions options)
    {
        if (options is null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        if (this.devToolsSession == null)
        {
            object? debuggerAddressObject = this.Capabilities.GetCapability(RemoteDevToolsEndPointCapabilityName);
            if (debuggerAddressObject is null)
            {
                throw new WebDriverException("Cannot find " + RemoteDevToolsEndPointCapabilityName + " capability for driver");
            }

            string debuggerAddress = debuggerAddressObject.ToString()!;

            if (!options.ProtocolVersion.HasValue || options.ProtocolVersion == DevToolsSession.AutoDetectDevToolsProtocolVersion)
            {
                object? versionObject = this.Capabilities.GetCapability(RemoteDevToolsVersionCapabilityName);
                if (versionObject is null)
                {
                    throw new WebDriverException("Cannot find " + RemoteDevToolsVersionCapabilityName + " capability for driver");
                }

                string version = versionObject.ToString()!;

                if (!int.TryParse(version.Substring(0, version.IndexOf(".")), out int devToolsProtocolVersion))
                {
                    throw new WebDriverException("Cannot parse protocol version from reported version string: " + version);
                }

                options.ProtocolVersion = devToolsProtocolVersion;
            }

            try
            {
                DevToolsSession session = new DevToolsSession(debuggerAddress, options);
                Task.Run(async () => await session.StartSession()).GetAwaiter().GetResult();
                this.devToolsSession = session;
            }
            catch (Exception e)
            {
                throw new WebDriverException("Unexpected error creating WebSocket DevTools session.", e);
            }
        }

        return this.devToolsSession;
    }

    /// <summary>
    /// Retrieves the downloadable files.
    /// </summary>
    /// <returns>A read-only list of file names available for download.</returns>
    public IReadOnlyList<string> GetDownloadableFiles()
    {
        var enableDownloads = this.Capabilities.GetCapability(CapabilityType.EnableDownloads);
        if (enableDownloads == null || !(bool)enableDownloads)
        {
            throw new WebDriverException("You must enable downloads in order to work with downloadable files.");
        }

        Response commandResponse = this.Execute(DriverCommand.GetDownloadableFiles, null);

        if (commandResponse.Value is not Dictionary<string, object?> value)
        {
            throw new WebDriverException("GetDownloadableFiles returned successfully, but response content was not an object: " + commandResponse.Value);
        }

        object?[] namesArray = (object?[])value["names"]!;
        return namesArray.Select(obj => obj!.ToString()!).ToList();
    }

    /// <summary>
    /// Downloads a file with the specified file name.
    /// </summary>
    /// <param name="fileName">The name of the file to be downloaded.</param>
    /// <param name="targetDirectory">The target directory where the file should be downloaded to.</param>
    /// <exception cref="ArgumentNullException">If <paramref name="targetDirectory"/> is null.</exception>
    public void DownloadFile(string fileName, string targetDirectory)
    {
        var enableDownloads = this.Capabilities.GetCapability(CapabilityType.EnableDownloads);
        if (enableDownloads == null || !(bool)enableDownloads)
        {
            throw new WebDriverException("You must enable downloads in order to work with downloadable files.");
        }

        Dictionary<string, object> parameters = new Dictionary<string, object>
        {
            { "name", fileName }
        };

        Response commandResponse = this.Execute(DriverCommand.DownloadFile, parameters);
        if (commandResponse.Value is not Dictionary<string, object?> value)
        {
            throw new WebDriverException("DownloadFile returned successfully, but response content was not an object: " + commandResponse.Value);
        }

        string contents = value["contents"]!.ToString()!;
        byte[] fileData = Convert.FromBase64String(contents);

        Directory.CreateDirectory(targetDirectory);

        using (var memoryReader = new MemoryStream(fileData))
        {
            using (var zipArchive = new ZipArchive(memoryReader, ZipArchiveMode.Read))
            {
                foreach (ZipArchiveEntry entry in zipArchive.Entries)
                {
                    string destinationPath = Path.Combine(targetDirectory, entry.FullName);

                    entry.ExtractToFile(destinationPath);
                }
            }
        }
    }

    /// <summary>
    /// Deletes all downloadable files.
    /// </summary>
    public void DeleteDownloadableFiles()
    {
        var enableDownloads = this.Capabilities.GetCapability(CapabilityType.EnableDownloads);
        if (enableDownloads == null || !(bool)enableDownloads)
        {
            throw new WebDriverException("You must enable downloads in order to work with downloadable files.");
        }

        this.Execute(DriverCommand.DeleteDownloadableFiles, null);
    }

    /// <summary>
    /// Closes a DevTools session.
    /// </summary>
    [RequiresUnreferencedCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    [RequiresDynamicCode(DevToolsSession.CDP_AOTIncompatibilityMessage)]
    public void CloseDevToolsSession()
    {
        if (this.devToolsSession != null)
        {
            Task.Run(async () => await this.devToolsSession.StopSession(true)).GetAwaiter().GetResult();
        }
    }

    /// <summary>
    /// Releases all resources associated with this <see cref="RemoteWebDriver"/>.
    /// </summary>
    /// <param name="disposing"><see langword="true"/> if the Dispose method was explicitly called; otherwise, <see langword="false"/>.</param>
    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (this.devToolsSession != null)
            {
                this.devToolsSession.Dispose();
                this.devToolsSession = null;
            }
        }

        base.Dispose(disposing);
    }

    private static ICapabilities ConvertOptionsToCapabilities(DriverOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options), "Driver options must not be null");
        }

        return options.ToCapabilities();
    }
}
